如果你來自 Node.js 的世界,你可能已經用過 Passport.js 或 jsonwebtoken。在 Express 中,你會手動組合中介軟體,精心管理 token 的每個生命週期。如果你來自 Spring Boot,你習慣了 Spring Security 的註解式配置,@PreAuthorize 和 @Secured 讓權限控制看起來優雅而簡潔。Python 的 FastAPI 則用依賴注入系統,讓認證邏輯像積木般組合。
今天我們要探討的是 Rails 如何處理認證這個永恆的話題。有趣的是,Rails 沒有內建的認證系統。這不是疏忽,而是刻意的設計決策。Rails 相信認證是如此重要且多樣化的需求,不應該用單一方案限制開發者的選擇。但這不代表你要從零開始,Rails 提供了所有必要的基礎設施,讓實作認證變得直觀而安全。
在我們即將建構的 LMS 系統中,認證是整個系統的基石。學生需要登入才能觀看課程,講師需要驗證身份才能上傳教材,管理員需要特殊權限才能管理系統。今天我們不只要實作一個能用的認證系統,更要建立一個能支撐複雜業務邏輯、易於擴展、安全可靠的認證架構。
Rails 誕生於 Web 2.0 時代,當時的應用主要是伺服器端渲染,Session 是最自然的選擇。Rails 的 session 機制設計得極其優雅,你只需要寫 session[:user_id] = user.id
,Rails 會處理所有的細節:加密、簽名、Cookie 管理。
但在 API 模式下,情況變得複雜了。前後端分離意味著可能有多個客戶端:網頁、手機 App、第三方整合。Session 依賴 Cookie,而 Cookie 在跨域請求中會遇到各種限制。這就是為什麼 JWT(JSON Web Token)成為了 API 認證的主流選擇。
讓我們深入比較這兩種方案在 Rails 中的實作:
維度 | Session-based | JWT-based |
---|---|---|
狀態管理 | 伺服器端保存狀態 | 無狀態,資訊包含在 token 中 |
擴展性 | 需要 session store(Redis/資料庫) | 不需要額外儲存 |
撤銷機制 | 簡單,刪除 session 即可 | 複雜,需要黑名單機制 |
資訊攜帶 | 只存 ID,其他資訊需查詢 | 可攜帶使用者資訊 |
安全考量 | CSRF 攻擊風險 | XSS 攻擊風險 |
Rails 支援 | 原生支援,零配置 | 需要自行實作或使用 gem |
JWT 不只是一個 token,它是一個自包含的安全憑證。理解它的結構對於正確使用至關重要:
# JWT 的三個部分:Header.Payload.Signature
#
# Header: 描述 token 類型和簽名演算法
# {
# "alg": "HS256",
# "typ": "JWT"
# }
#
# Payload: 攜帶的資訊(claims)
# {
# "sub": "1234567890", # subject - 使用者 ID
# "exp": 1516239022, # expiration - 過期時間
# "iat": 1516238022, # issued at - 簽發時間
# "role": "student" # 自定義資訊
# }
#
# Signature: 確保 token 未被竄改
# HMACSHA256(
# base64UrlEncode(header) + "." +
# base64UrlEncode(payload),
# secret
# )
Rails 的設計哲學告訴我們:安全不應該是事後的考量。當我們實作 JWT 時,需要考慮幾個關鍵的安全原則:
最小權限原則:Payload 中只放必要的資訊。不要為了減少資料庫查詢而放入敏感資料。
時間窗口控制:短期的 access token 配合長期的 refresh token,平衡安全性和使用者體驗。
密鑰管理:使用 Rails 的 credentials 系統管理密鑰,確保密鑰不會進入版本控制。
我們從最基礎的 JWT 編碼和解碼開始。Rails 鼓勵將業務邏輯封裝在服務物件中,這讓程式碼更容易測試和維護:
# app/services/jwt_service.rb
class JwtService
# 使用 Rails 的密鑰管理系統
# 在 credentials.yml.enc 中設定 secret_key_base
SECRET_KEY = Rails.application.credentials.secret_key_base
# Token 有效期設定
ACCESS_TOKEN_EXPIRY = 15.minutes
REFRESH_TOKEN_EXPIRY = 7.days
class << self
def encode(payload, exp = ACCESS_TOKEN_EXPIRY.from_now)
# 加入標準 claims
payload = payload.dup
payload[:iat] = Time.current.to_i # issued at
payload[:exp] = exp.to_i # expiration
payload[:jti] = SecureRandom.uuid # JWT ID,用於撤銷
JWT.encode(payload, SECRET_KEY, 'HS256')
end
def decode(token)
# 解碼並驗證 token
decoded = JWT.decode(
token,
SECRET_KEY,
true, # 驗證簽名
{
algorithm: 'HS256',
verify_iat: true, # 驗證簽發時間
verify_exp: true # 驗證過期時間
}
)
# JWT.decode 返回陣列,第一個元素是 payload
HashWithIndifferentAccess.new(decoded[0])
rescue JWT::ExpiredSignature
raise TokenExpiredError, '認證已過期,請重新登入'
rescue JWT::InvalidIatError
raise TokenInvalidError, '無效的認證時間'
rescue JWT::DecodeError => e
raise TokenInvalidError, "認證解析失敗:#{e.message}"
end
def refresh(token)
# 解碼舊 token(即使已過期)
decoded = JWT.decode(token, SECRET_KEY, true, { verify_exp: false })
payload = decoded[0]
# 檢查是否在可重新整理的時間窗口內
issued_at = Time.at(payload['iat'])
if issued_at < REFRESH_TOKEN_EXPIRY.ago
raise TokenExpiredError, '認證已完全過期,請重新登入'
end
# 簽發新 token,保留原有資訊但更新時間
new_payload = payload.except('iat', 'exp', 'jti')
encode(new_payload)
end
end
end
# 自定義錯誤類別,讓錯誤處理更清晰
class TokenExpiredError < StandardError; end
class TokenInvalidError < StandardError; end
接下來,我們需要將認證邏輯整合到控制器中。Rails 的 before_action
提供了優雅的方式來處理這類橫切關注點:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
# 引入必要的模組,即使是 API 模式
include ActionController::HttpAuthentication::Token::ControllerMethods
# 定義認證相關的 callbacks
before_action :authenticate_request, if: :authentication_required?
private
def authenticate_request
token = extract_token_from_header
if token.present?
begin
@decoded_token = JwtService.decode(token)
@current_user = User.find(@decoded_token[:user_id])
# 檢查 token 是否被撤銷(使用 Redis 實作黑名單)
if token_revoked?(@decoded_token[:jti])
render_unauthorized('認證已被撤銷')
end
rescue TokenExpiredError => e
# 嘗試自動重新整理 token
handle_expired_token(token, e)
rescue TokenInvalidError, ActiveRecord::RecordNotFound => e
render_unauthorized(e.message)
end
else
render_unauthorized('缺少認證資訊')
end
end
def extract_token_from_header
# 支援多種 token 傳遞方式
# 1. Authorization: Bearer <token>
authenticate_with_http_token { |token, _options| return token }
# 2. 自定義 header: X-Auth-Token
request.headers['X-Auth-Token']
end
def handle_expired_token(token, error)
# 檢查是否有 refresh token(通常在另一個 header 中)
refresh_token = request.headers['X-Refresh-Token']
if refresh_token.present?
begin
new_token = JwtService.refresh(refresh_token)
response.headers['X-New-Token'] = new_token
# 繼續處理請求,使用新 token 的資訊
@decoded_token = JwtService.decode(new_token)
@current_user = User.find(@decoded_token[:user_id])
rescue StandardError
render_unauthorized(error.message)
end
else
render_unauthorized(error.message)
end
end
def token_revoked?(jti)
# 使用 Redis 維護撤銷的 token 黑名單
# 這是處理 JWT 無法主動撤銷的常見模式
Rails.cache.exist?("revoked_token:#{jti}")
end
def authentication_required?
# 預設所有請求都需要認證
# 子類別可以覆寫這個方法來定義公開的端點
true
end
def render_unauthorized(message = '未授權的請求')
render json: {
error: message,
code: 'UNAUTHORIZED'
}, status: :unauthorized
end
# 提供給子類別使用的輔助方法
attr_reader :current_user, :decoded_token
def logged_in?
current_user.present?
end
end
現在我們需要實作登入、登出和重新整理 token 的端點:
# app/controllers/api/v1/auth_controller.rb
module Api
module V1
class AuthController < ApplicationController
# 登入和註冊不需要認證
skip_before_action :authenticate_request, only: [:login, :register]
def register
user = User.new(user_params)
if user.save
# 註冊成功後自動登入
tokens = generate_tokens(user)
render_login_success(user, tokens)
else
render_validation_errors(user.errors)
end
end
def login
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
# 記錄登入資訊
user.update(
last_login_at: Time.current,
last_login_ip: request.remote_ip
)
tokens = generate_tokens(user)
render_login_success(user, tokens)
else
# 避免透露帳號是否存在
render_unauthorized('電子郵件或密碼錯誤')
end
end
def logout
# 將 token 加入黑名單
jti = decoded_token[:jti]
expires_at = Time.at(decoded_token[:exp])
# 設定黑名單項目的過期時間與 token 相同
# 這樣不會無限累積黑名單項目
Rails.cache.write(
"revoked_token:#{jti}",
true,
expires_in: expires_at - Time.current
)
render json: { message: '登出成功' }
end
def refresh
# 使用 refresh token 獲取新的 access token
refresh_token = params[:refresh_token]
if refresh_token.present?
begin
decoded = JwtService.decode(refresh_token)
user = User.find(decoded[:user_id])
# 檢查 refresh token 類型
unless decoded[:token_type] == 'refresh'
raise TokenInvalidError, '無效的 token 類型'
end
# 生成新的 access token
access_token = JwtService.encode(
user_id: user.id,
email: user.email,
token_type: 'access'
)
render json: {
access_token: access_token,
expires_in: JwtService::ACCESS_TOKEN_EXPIRY
}
rescue StandardError => e
render_unauthorized(e.message)
end
else
render_unauthorized('缺少 refresh token')
end
end
def me
# 返回當前使用者資訊
render json: UserSerializer.new(current_user).serializable_hash
end
private
def generate_tokens(user)
# 生成 access token 和 refresh token
access_payload = {
user_id: user.id,
email: user.email,
token_type: 'access'
}
refresh_payload = {
user_id: user.id,
token_type: 'refresh'
}
{
access_token: JwtService.encode(access_payload),
refresh_token: JwtService.encode(
refresh_payload,
JwtService::REFRESH_TOKEN_EXPIRY.from_now
)
}
end
def render_login_success(user, tokens)
render json: {
user: UserSerializer.new(user).serializable_hash,
access_token: tokens[:access_token],
refresh_token: tokens[:refresh_token],
expires_in: JwtService::ACCESS_TOKEN_EXPIRY
}
end
def render_validation_errors(errors)
render json: {
error: '驗證失敗',
details: errors.full_messages
}, status: :unprocessable_entity
end
def user_params
params.require(:user).permit(:email, :password, :name)
end
end
end
end
我們的 LMS 系統有獨特的認證挑戰。不同於一般的應用,LMS 需要處理多種使用者角色和複雜的權限場景:
多重身份問題:同一個使用者可能在不同課程中有不同角色。Alice 可能是「Rails 入門」的學生,同時是「Ruby 基礎」的助教。
時效性控制:課程可能有開課和結課時間,認證需要考慮時間因素。
第三方整合:LMS 可能需要與企業的 SSO 系統整合,支援 SAML 或 OAuth。
讓我們實作一個能處理這些複雜需求的認證系統:
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
# 使用者在不同課程中的角色
has_many :course_memberships
has_many :courses, through: :course_memberships
# 全域角色
enum global_role: {
student: 0,
instructor: 1,
admin: 2
}
# 認證相關的驗證
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 8 }, if: :password_required?
# 智慧方法:檢查在特定課程中的角色
def role_in_course(course)
membership = course_memberships.find_by(course: course)
membership&.role
end
def enrolled_in?(course)
course_memberships.exists?(course: course)
end
def can_access_course?(course)
# 管理員可以存取所有課程
return true if admin?
# 檢查是否註冊且在有效期內
membership = course_memberships.find_by(course: course)
return false unless membership
# 檢查課程是否在開課期間
course.active? && membership.active?
end
private
def password_required?
new_record? || password.present?
end
end
# app/models/course_membership.rb
class CourseMembership < ApplicationRecord
belongs_to :user
belongs_to :course
enum role: {
student: 0,
teaching_assistant: 1,
instructor: 2
}
enum status: {
active: 0,
suspended: 1,
completed: 2,
dropped: 3
}
# 註冊時間和過期時間
validates :enrolled_at, presence: true
scope :active, -> { where(status: :active) }
scope :current, -> { active.where('expires_at IS NULL OR expires_at > ?', Time.current) }
def active?
status == 'active' && (expires_at.nil? || expires_at > Time.current)
end
end
我們需要擴展 JWT 服務來支援課程相關的認證資訊:
# app/services/lms_jwt_service.rb
class LmsJwtService < JwtService
class << self
def encode_for_course(user, course)
# 為特定課程生成 token
# 這種 token 只能存取該課程的資源
membership = user.course_memberships.find_by(course: course)
raise TokenInvalidError, '使用者未註冊此課程' unless membership
raise TokenInvalidError, '使用者在此課程的權限已失效' unless membership.active?
payload = {
user_id: user.id,
course_id: course.id,
course_role: membership.role,
membership_id: membership.id,
scope: 'course',
expires_at: membership.expires_at
}
# 如果課程有結束時間,token 不應超過該時間
exp = [
ACCESS_TOKEN_EXPIRY.from_now,
course.ends_at,
membership.expires_at
].compact.min
encode(payload, exp)
end
def encode_for_exam(user, exam)
# 考試專用 token,時效性更短,權限更受限
enrollment = user.course_memberships.find_by(course: exam.course)
raise TokenInvalidError, '無權參加此考試' unless enrollment&.active?
# 考試 token 的有效期就是考試時間
payload = {
user_id: user.id,
exam_id: exam.id,
scope: 'exam',
started_at: Time.current,
must_finish_by: exam.duration.from_now
}
encode(payload, exam.duration.from_now)
end
end
end
# app/controllers/api/v1/course_auth_controller.rb
module Api
module V1
class CourseAuthController < ApplicationController
before_action :set_course
def request_access
# 請求存取特定課程的 token
if current_user.can_access_course?(@course)
token = LmsJwtService.encode_for_course(current_user, @course)
render json: {
course_token: token,
course: CourseSerializer.new(@course).serializable_hash,
role: current_user.role_in_course(@course),
expires_in: JwtService::ACCESS_TOKEN_EXPIRY
}
else
render json: {
error: '無權存取此課程',
enrollment_url: api_v1_course_enrollments_url(@course)
}, status: :forbidden
end
end
private
def set_course
@course = Course.find(params[:course_id])
end
end
end
end
許多企業客戶要求 LMS 支援他們的 SSO 系統。我們可以擴展認證系統來支援這個需求:
# app/services/sso_service.rb
class SsoService
def self.authenticate_via_saml(saml_response)
# 解析 SAML 回應
response = OneLogin::RubySaml::Response.new(saml_response)
if response.is_valid?
# 從 SAML 回應中提取使用者資訊
email = response.nameid
attributes = response.attributes
# 尋找或建立使用者
user = User.find_or_initialize_by(email: email)
if user.new_record?
# 首次 SSO 登入,建立帳號
user.assign_attributes(
name: attributes['name'],
sso_provider: 'saml',
sso_uid: response.nameid,
# SSO 使用者不需要密碼
password: SecureRandom.hex(32)
)
user.save!
end
# 更新 SSO 資訊
user.update(
last_sso_login_at: Time.current,
sso_attributes: attributes.to_h
)
# 生成 JWT token
JwtService.encode(
user_id: user.id,
email: user.email,
auth_method: 'sso'
)
else
raise TokenInvalidError, "SSO 認證失敗:#{response.errors.join(', ')}"
end
end
def self.authenticate_via_oauth(provider, auth_hash)
# 處理 OAuth 認證(Google, GitHub 等)
user = User.find_or_initialize_by(
sso_provider: provider,
sso_uid: auth_hash['uid']
)
user.assign_attributes(
email: auth_hash['info']['email'],
name: auth_hash['info']['name'],
avatar_url: auth_hash['info']['image']
)
user.password = SecureRandom.hex(32) if user.new_record?
user.save!
JwtService.encode(
user_id: user.id,
email: user.email,
auth_method: "oauth_#{provider}"
)
end
end
誤區 1:過度依賴 JWT 儲存資訊
來自 Node.js 背景的開發者常常想在 JWT 中塞入大量資訊,以減少資料庫查詢。這在 Express 的無狀態設計中很常見,但在 Rails 中,我們有更好的解決方案。
錯誤做法:
# 不要這樣做 - JWT 變得過大且包含敏感資訊
payload = {
user_id: user.id,
email: user.email,
name: user.name,
avatar_url: user.avatar_url,
preferences: user.preferences,
permissions: user.all_permissions,
courses: user.courses.pluck(:id, :name)
}
正確做法:
# JWT 只包含識別資訊
payload = {
user_id: user.id,
email: user.email
}
# 使用 Rails 的快取機制儲存常用資訊
Rails.cache.fetch("user_context:#{user.id}", expires_in: 1.hour) do
{
name: user.name,
avatar_url: user.avatar_url,
preferences: user.preferences
}
end
誤區 2:忽略 Rails 的安全機制
Spring Boot 開發者習慣了框架自動處理的安全性,可能會忽略 Rails API 模式下需要手動處理的部分。
需要注意的安全要點:
class ApplicationController < ActionController::API
# API 模式下仍然需要 CORS 設定
# 使用 rack-cors gem
# 防止時序攻擊
def secure_compare(a, b)
return false unless a.bytesize == b.bytesize
l = a.unpack("C*")
r = b.unpack("C*")
result = 0
l.zip(r) { |x, y| result |= x ^ y }
result == 0
end
# 限制請求頻率
# 使用 rack-attack gem
end
Token 儲存策略
不同的儲存位置有不同的安全風險:
// 前端儲存策略參考
// 1. localStorage - 簡單但有 XSS 風險
localStorage.setItem('token', token);
// 2. httpOnly Cookie - 防止 XSS 但有 CSRF 風險
// 需要後端配合設定
// 3. 記憶體 + httpOnly Cookie 的混合方案(推薦)
// access token 在記憶體
// refresh token 在 httpOnly Cookie
class TokenManager {
constructor() {
this.accessToken = null;
}
setAccessToken(token) {
this.accessToken = token;
}
getAccessToken() {
return this.accessToken;
}
// refresh token 透過 httpOnly Cookie 自動傳送
}
實作 Rate Limiting
# config/initializers/rack_attack.rb
Rack::Attack.throttle('api/login', limit: 5, period: 1.minute) do |req|
if req.path == '/api/v1/auth/login' && req.post?
req.ip
end
end
Rack::Attack.throttle('api/refresh', limit: 10, period: 1.hour) do |req|
if req.path == '/api/v1/auth/refresh' && req.post?
req.ip
end
end
測試是確保認證系統可靠的關鍵:
# spec/requests/api/v1/auth_spec.rb
require 'rails_helper'
RSpec.describe 'Api::V1::Auth', type: :request do
describe 'POST /api/v1/auth/login' do
let(:user) { create(:user, email: 'test@example.com', password: 'password123') }
context '使用正確的認證資訊' do
it '返回 JWT token' do
post '/api/v1/auth/login', params: {
email: 'test@example.com',
password: 'password123'
}
expect(response).to have_http_status(:success)
json = JSON.parse(response.body)
expect(json['access_token']).to be_present
expect(json['refresh_token']).to be_present
# 驗證 token 可以被解碼
decoded = JwtService.decode(json['access_token'])
expect(decoded[:user_id]).to eq(user.id)
end
end
context '使用錯誤的密碼' do
it '返回未授權錯誤' do
post '/api/v1/auth/login', params: {
email: 'test@example.com',
password: 'wrongpassword'
}
expect(response).to have_http_status(:unauthorized)
expect(JSON.parse(response.body)['error']).to eq('電子郵件或密碼錯誤')
end
end
context 'Token 過期處理' do
it '自動重新整理過期的 token' do
# 建立一個即將過期的 token
expired_token = JwtService.encode(
{ user_id: user.id },
1.second.from_now
)
sleep 2 # 等待 token 過期
# 發送請求時附帶 refresh token
refresh_token = JwtService.encode(
{ user_id: user.id, token_type: 'refresh' },
7.days.from_now
)
get '/api/v1/auth/me', headers: {
'Authorization' => "Bearer #{expired_token}",
'X-Refresh-Token' => refresh_token
}
# 應該在 header 中返回新 token
expect(response.headers['X-New-Token']).to be_present
end
end
end
end
建立一個基本的 Rails API 專案,實作 JWT 認證的核心功能。這個練習會幫助你理解認證系統的基本架構,並熟悉 Rails 中處理認證的模式。
步驟 1:建立新專案並安裝依賴
# 建立新的 Rails API 專案
rails new jwt_auth_practice --api --database=postgresql
cd jwt_auth_practice
# 在 Gemfile 中加入必要的 gem
# 編輯 Gemfile,加入以下內容:
# Gemfile
gem 'jwt'
gem 'bcrypt'
gem 'rack-cors'
group :development, :test do
gem 'rspec-rails'
gem 'factory_bot_rails'
end
# 安裝依賴
bundle install
# 設定資料庫
rails db:create
步驟 2:建立 User 模型
# 生成 User 模型
rails generate model User email:string:uniq password_digest:string name:string
rails db:migrate
步驟 3:設定 User 模型
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
validates :email, presence: true,
uniqueness: { case_sensitive: false },
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 6 }, if: :password_required?
before_save :downcase_email
private
def downcase_email
self.email = email.downcase
end
def password_required?
password_digest.nil? || password.present?
end
end
步驟 4:實作 JWT 服務
# app/services/json_web_token.rb
class JsonWebToken
SECRET_KEY = Rails.application.credentials.secret_key_base || 'your-secret-key'
def self.encode(payload, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, SECRET_KEY)
end
def self.decode(token)
decoded = JWT.decode(token, SECRET_KEY)[0]
HashWithIndifferentAccess.new(decoded)
rescue JWT::DecodeError => e
raise ExceptionHandler::InvalidToken, e.message
end
end
步驟 5:建立例外處理模組
# app/controllers/concerns/exception_handler.rb
module ExceptionHandler
extend ActiveSupport::Concern
class InvalidToken < StandardError; end
class MissingToken < StandardError; end
included do
rescue_from ExceptionHandler::InvalidToken do |e|
render json: { error: e.message }, status: :unauthorized
end
rescue_from ExceptionHandler::MissingToken do |e|
render json: { error: e.message }, status: :unprocessable_entity
end
rescue_from ActiveRecord::RecordNotFound do |e|
render json: { error: e.message }, status: :not_found
end
end
end
步驟 6:設定 ApplicationController
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
include ExceptionHandler
before_action :authorize_request
attr_reader :current_user
private
def authorize_request
header = request.headers['Authorization']
header = header.split(' ').last if header
begin
decoded = JsonWebToken.decode(header)
@current_user = User.find(decoded[:user_id])
rescue ActiveRecord::RecordNotFound => e
render json: { error: e.message }, status: :unauthorized
rescue JWT::DecodeError => e
render json: { error: e.message }, status: :unauthorized
end
end
end
步驟 7:實作認證控制器
# app/controllers/authentication_controller.rb
class AuthenticationController < ApplicationController
skip_before_action :authorize_request, only: [:login, :register]
def register
user = User.new(user_params)
if user.save
token = JsonWebToken.encode(user_id: user.id)
render json: {
token: token,
user: { id: user.id, email: user.email, name: user.name }
}, status: :created
else
render json: { errors: user.errors.full_messages },
status: :unprocessable_entity
end
end
def login
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
token = JsonWebToken.encode(user_id: user.id)
render json: {
token: token,
user: { id: user.id, email: user.email, name: user.name }
}, status: :ok
else
render json: { error: '無效的認證資訊' }, status: :unauthorized
end
end
private
def user_params
params.permit(:email, :password, :name)
end
end
步驟 8:設定路由
# config/routes.rb
Rails.application.routes.draw do
post 'auth/register', to: 'authentication#register'
post 'auth/login', to: 'authentication#login'
# 測試用的受保護路由
get 'profile', to: 'users#profile'
end
步驟 9:建立測試用的 UsersController
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def profile
render json: {
user: {
id: current_user.id,
email: current_user.email,
name: current_user.name
}
}
end
end
使用 curl 或 Postman 測試你的 API:
# 1. 註冊新使用者
curl -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "password123",
"name": "Test User"
}'
# 預期回應:
# {
# "token": "eyJhbGciOiJIUzI1NiJ9...",
# "user": {
# "id": 1,
# "email": "test@example.com",
# "name": "Test User"
# }
# }
# 2. 登入
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "password123"
}'
# 3. 存取受保護的路由(使用上面獲得的 token)
curl http://localhost:3000/profile \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
# 預期回應:
# {
# "user": {
# "id": 1,
# "email": "test@example.com",
# "name": "Test User"
# }
# }
錯誤:uninitialized constant JsonWebToken
app/services/json_web_token.rb
檔案存在錯誤:NoMethodError: undefined method 'authenticate'
has_secure_password
password_digest
欄位Token 解碼失敗
擴展基礎練習,加入多角色支援、refresh token 機制,以及 token 撤銷功能。這個挑戰會模擬 LMS 系統的真實需求。
步驟 1:擴展 User 模型支援角色
# 建立遷移檔案
rails generate migration AddRoleToUsers role:integer
rails generate model Course name:string description:text
rails generate model Enrollment user:references course:references role:integer status:integer
rails db:migrate
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
has_many :enrollments
has_many :courses, through: :enrollments
enum role: {
student: 0,
teacher: 1,
admin: 2
}
# 既有的驗證...
def role_for_course(course)
enrollments.find_by(course: course)&.role || 'none'
end
end
# app/models/enrollment.rb
class Enrollment < ApplicationRecord
belongs_to :user
belongs_to :course
enum role: {
student: 0,
teaching_assistant: 1,
instructor: 2
}
enum status: {
active: 0,
completed: 1,
dropped: 2
}
end
步驟 2:實作進階 JWT 服務
# app/services/jwt_token_service.rb
class JwtTokenService
SECRET_KEY = Rails.application.credentials.secret_key_base
class << self
def generate_tokens(user)
{
access_token: encode_access_token(user),
refresh_token: encode_refresh_token(user)
}
end
def encode_access_token(user)
payload = {
user_id: user.id,
email: user.email,
role: user.role,
exp: 15.minutes.from_now.to_i,
iat: Time.current.to_i,
jti: SecureRandom.uuid
}
JWT.encode(payload, SECRET_KEY, 'HS256')
end
def encode_refresh_token(user)
payload = {
user_id: user.id,
exp: 7.days.from_now.to_i,
iat: Time.current.to_i,
jti: SecureRandom.uuid,
token_type: 'refresh'
}
JWT.encode(payload, SECRET_KEY, 'HS256')
end
def decode(token)
JWT.decode(token, SECRET_KEY, true, algorithm: 'HS256')[0]
rescue JWT::ExpiredSignature
raise TokenExpiredError
rescue JWT::DecodeError
raise TokenInvalidError
end
def refresh_access_token(refresh_token)
payload = decode(refresh_token)
unless payload['token_type'] == 'refresh'
raise TokenInvalidError, 'Not a refresh token'
end
user = User.find(payload['user_id'])
encode_access_token(user)
rescue ActiveRecord::RecordNotFound
raise TokenInvalidError, 'User not found'
end
end
end
class TokenExpiredError < StandardError; end
class TokenInvalidError < StandardError; end
步驟 3:實作 Token 黑名單(使用 Rails 快取)
# app/services/token_blacklist.rb
class TokenBlacklist
def self.add(jti, exp)
expires_in = Time.at(exp) - Time.current
Rails.cache.write("blacklist:#{jti}", true, expires_in: expires_in)
end
def self.blacklisted?(jti)
Rails.cache.exist?("blacklist:#{jti}")
end
end
步驟 4:更新 ApplicationController
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
before_action :authenticate_request
attr_reader :current_user, :current_token
private
def authenticate_request
header = request.headers['Authorization']
token = header.split(' ').last if header
if token.nil?
render json: { error: '缺少認證 token' }, status: :unauthorized
return
end
begin
@current_token = JwtTokenService.decode(token)
# 檢查黑名單
if TokenBlacklist.blacklisted?(@current_token['jti'])
render json: { error: 'Token 已被撤銷' }, status: :unauthorized
return
end
@current_user = User.find(@current_token['user_id'])
rescue TokenExpiredError
handle_expired_token
rescue TokenInvalidError => e
render json: { error: e.message }, status: :unauthorized
rescue ActiveRecord::RecordNotFound
render json: { error: '使用者不存在' }, status: :unauthorized
end
end
def handle_expired_token
refresh_token = request.headers['X-Refresh-Token']
if refresh_token
begin
new_access_token = JwtTokenService.refresh_access_token(refresh_token)
response.headers['X-New-Access-Token'] = new_access_token
# 解碼新 token 並設定 current_user
@current_token = JwtTokenService.decode(new_access_token)
@current_user = User.find(@current_token['user_id'])
rescue StandardError => e
render json: { error: 'Token 重新整理失敗' }, status: :unauthorized
end
else
render json: { error: 'Token 已過期' }, status: :unauthorized
end
end
# 授權輔助方法
def authorize_admin!
unless current_user&.admin?
render json: { error: '需要管理員權限' }, status: :forbidden
end
end
def authorize_teacher!
unless current_user&.teacher? || current_user&.admin?
render json: { error: '需要教師權限' }, status: :forbidden
end
end
end
步驟 5:實作完整的認證控制器
# app/controllers/api/v1/auth_controller.rb
module Api
module V1
class AuthController < ApplicationController
skip_before_action :authenticate_request, only: [:login, :register]
def register
user = User.new(registration_params)
user.role = :student # 預設角色
if user.save
tokens = JwtTokenService.generate_tokens(user)
render json: {
message: '註冊成功',
user: user_response(user),
access_token: tokens[:access_token],
refresh_token: tokens[:refresh_token]
}, status: :created
else
render json: {
errors: user.errors.full_messages
}, status: :unprocessable_entity
end
end
def login
user = User.find_by(email: params[:email]&.downcase)
if user&.authenticate(params[:password])
tokens = JwtTokenService.generate_tokens(user)
render json: {
message: '登入成功',
user: user_response(user),
access_token: tokens[:access_token],
refresh_token: tokens[:refresh_token]
}
else
render json: {
error: '電子郵件或密碼錯誤'
}, status: :unauthorized
end
end
def logout
# 將當前 token 加入黑名單
jti = current_token['jti']
exp = current_token['exp']
TokenBlacklist.add(jti, exp)
render json: { message: '登出成功' }
end
def refresh
refresh_token = params[:refresh_token]
if refresh_token.present?
begin
new_access_token = JwtTokenService.refresh_access_token(refresh_token)
render json: {
access_token: new_access_token
}
rescue StandardError => e
render json: {
error: "Token 重新整理失敗: #{e.message}"
}, status: :unauthorized
end
else
render json: {
error: '需要提供 refresh token'
}, status: :bad_request
end
end
def me
render json: {
user: user_response(current_user),
enrollments: current_user.enrollments.includes(:course).map do |e|
{
course_id: e.course_id,
course_name: e.course.name,
role: e.role,
status: e.status
}
end
}
end
private
def registration_params
params.permit(:email, :password, :name)
end
def user_response(user)
{
id: user.id,
email: user.email,
name: user.name,
role: user.role
}
end
end
end
end
步驟 6:加入課程權限檢查
# app/controllers/api/v1/courses_controller.rb
module Api
module V1
class CoursesController < ApplicationController
before_action :set_course, only: [:show, :update, :destroy]
before_action :authorize_course_access!, only: [:show]
before_action :authorize_course_instructor!, only: [:update, :destroy]
def index
courses = if current_user.admin?
Course.all
else
current_user.courses
end
render json: courses
end
def show
render json: @course
end
def create
authorize_teacher!
course = Course.new(course_params)
if course.save
# 建立者自動成為講師
Enrollment.create!(
user: current_user,
course: course,
role: :instructor,
status: :active
)
render json: course, status: :created
else
render json: { errors: course.errors.full_messages },
status: :unprocessable_entity
end
end
def update
if @course.update(course_params)
render json: @course
else
render json: { errors: @course.errors.full_messages },
status: :unprocessable_entity
end
end
def destroy
@course.destroy
render json: { message: '課程已刪除' }
end
private
def set_course
@course = Course.find(params[:id])
end
def course_params
params.permit(:name, :description)
end
def authorize_course_access!
unless current_user.admin? || current_user.enrolled_in?(@course)
render json: { error: '無權存取此課程' }, status: :forbidden
end
end
def authorize_course_instructor!
enrollment = current_user.enrollments.find_by(course: @course)
unless current_user.admin? || enrollment&.instructor?
render json: { error: '需要講師權限' }, status: :forbidden
end
end
end
end
end
# spec/requests/api/v1/auth_spec.rb
require 'rails_helper'
RSpec.describe 'Authentication', type: :request do
let(:user) { create(:user, email: 'test@example.com', password: 'password123') }
describe 'POST /api/v1/auth/login' do
context 'with valid credentials' do
it 'returns access and refresh tokens' do
post '/api/v1/auth/login', params: {
email: 'test@example.com',
password: 'password123'
}
expect(response).to have_http_status(:success)
json = JSON.parse(response.body)
expect(json['access_token']).to be_present
expect(json['refresh_token']).to be_present
expect(json['user']['email']).to eq('test@example.com')
end
end
context 'with invalid credentials' do
it 'returns unauthorized error' do
post '/api/v1/auth/login', params: {
email: 'test@example.com',
password: 'wrong_password'
}
expect(response).to have_http_status(:unauthorized)
json = JSON.parse(response.body)
expect(json['error']).to eq('電子郵件或密碼錯誤')
end
end
end
describe 'POST /api/v1/auth/logout' do
it 'blacklists the current token' do
tokens = JwtTokenService.generate_tokens(user)
post '/api/v1/auth/logout', headers: {
'Authorization' => "Bearer #{tokens[:access_token]}"
}
expect(response).to have_http_status(:success)
# 嘗試使用已登出的 token
get '/api/v1/auth/me', headers: {
'Authorization' => "Bearer #{tokens[:access_token]}"
}
expect(response).to have_http_status(:unauthorized)
json = JSON.parse(response.body)
expect(json['error']).to eq('Token 已被撤銷')
end
end
describe 'Token refresh' do
it 'generates new access token with valid refresh token' do
tokens = JwtTokenService.generate_tokens(user)
post '/api/v1/auth/refresh', params: {
refresh_token: tokens[:refresh_token]
}
expect(response).to have_http_status(:success)
json = JSON.parse(response.body)
expect(json['access_token']).to be_present
# 新 token 應該可以使用
get '/api/v1/auth/me', headers: {
'Authorization' => "Bearer #{json['access_token']}"
}
expect(response).to have_http_status(:success)
end
end
end
# 1. 執行測試
bundle exec rspec
# 2. 手動測試流程
# 註冊教師帳號
curl -X POST http://localhost:3000/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "teacher@example.com",
"password": "password123",
"name": "Teacher User"
}'
# 登入並獲取 tokens
curl -X POST http://localhost:3000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "teacher@example.com",
"password": "password123"
}'
# 使用 access token 建立課程
curl -X POST http://localhost:3000/api/v1/courses \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Rails 入門",
"description": "學習 Rails 基礎"
}'
# 測試 token 重新整理
curl -X POST http://localhost:3000/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{
"refresh_token": "YOUR_REFRESH_TOKEN"
}'
# 登出(撤銷 token)
curl -X POST http://localhost:3000/api/v1/auth/logout \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
完成這個進階挑戰後,思考以下問題:
安全性改進:如何防止 refresh token 被濫用?考慮實作 refresh token rotation。
效能優化:當使用者數量增加時,如何優化 token 驗證的效能?
擴展性:如何支援第三方登入(OAuth)?需要修改哪些部分?
監控與稽核:如何追蹤異常的登入行為?考慮加入登入日誌和異常檢測。
這些練習和挑戰提供了從基礎到進階的完整學習路徑。基礎練習讓你熟悉 JWT 的核心概念,進階挑戰則模擬了真實專案的複雜需求。透過實作這些功能,你將深入理解 Rails 中認證系統的設計和實作細節。
我們今天建構的認證系統建立在前面幾天的基礎上:
Day 4 的 ActiveRecord 基礎:我們使用了 has_secure_password
,這是 ActiveRecord 提供的密碼處理機制。理解模型層如何與認證整合是關鍵。
Day 6 的控制器模式:before_action
的使用展示了 Rails 如何優雅地處理橫切關注點。認證是最典型的應用場景。
Day 8 的關聯設計:使用者與課程的多對多關係透過 course_memberships
實現,這種設計讓我們能靈活處理複雜的權限場景。
明天(Day 10)我們將深入授權系統,探討如何在認證的基礎上實作細粒度的權限控制。今天的 JWT payload 設計將直接影響明天的授權實作。
今天實作的認證系統將在後續的學習中不斷被使用和擴展:
知識層面:
我們學會了如何從零實作 JWT 認證系統,理解了 token 的結構和安全機制,掌握了 Rails 中處理認證的慣例和模式。
思維層面:
理解了 Rails 為什麼不內建認證系統 — 這給了我們自由度去選擇最適合的方案。同時體會到 Rails 提供的基礎設施如何讓認證實作變得簡潔。
實踐層面:
能夠建構一個生產級的認證系統,處理 token 過期、自動重新整理、多角色支援等實際需求。這個系統將成為 LMS 的基石。
完成今天的學習後,你應該能夠:
深入閱讀:
相關 Gem:
jwt
:JWT 編碼和解碼bcrypt
:密碼加密(has_secure_password 依賴)rack-cors
:處理跨域請求rack-attack
:請求限流和防護明天我們將探討授權與權限管理。如果說今天學習的認證是確認「你是誰」,那明天就是判斷「你能做什麼」。我們將實作基於角色的存取控制(RBAC),處理 LMS 中複雜的權限場景。準備好深入權限的迷宮了嗎?讓我們繼續這段旅程。